昨天我們學會了測試結構與組織,但隨著測試越寫越多,你可能遇到一個問題:「為什麼這個測試單獨執行會通過,但和其他測試一起執行時會失敗?」
想像一個場景:你為數學工具庫新增了一個 CalculatorWithHistory
類別,它會記錄計算歷史。第一個測試執行時歷史是空的,測試通過;但第二個測試執行時,歷史裡已經有第一個測試留下的資料,導致測試失敗。這就是「測試污染」問題。
今天我們要學習「測試生命週期」,了解如何在每個測試執行前後進行適當的設置和清理,讓每個測試都能在乾淨、一致的環境中執行。
今天結束後,你將學會:
第一階段:打好基礎(Day 1-10)
├── Day 01 - 環境設置與第一個測試
├── Day 02 - 認識斷言(Assertions)
├── Day 03 - TDD 紅綠重構循環
├── Day 04 - 測試結構與組織
├── Day 05 - 測試生命週期 ★ 今天在這裡
├── ...
└── (更多精彩內容待續)
每個測試的執行都會經過以下階段:
設置階段 → 執行階段 → 斷言階段 → 清理階段
(Setup) (Execute) (Assert) (Cleanup)
讓我們先看看沒有適當生命週期管理的測試:
// 問題:測試之間會互相影響
$calculator = new CalculatorWithHistory();
it('performs add operation', function() use ($calculator) {
$result = $calculator->add(2, 3);
expect($result)->toBe(5);
expect($calculator->getHistory())->toHaveCount(1);
});
it('performs multiply operation', function() use ($calculator) {
$result = $calculator->multiply(4, 5);
expect($result)->toBe(20);
expect($calculator->getHistory())->toHaveCount(1); // ❌ 實際是 2!
});
第二個測試會失敗,因為計算器的歷史記錄還保留著第一個測試的資料。
測試隔離是指每個測試案例都應該:
// ✅ 好的測試:每個測試都是獨立的
describe('Calculator with History', function() {
beforeEach(function() {
$this->calculator = new CalculatorWithHistory();
});
it('performs add operation', function() {
$result = $this->calculator->add(2, 3);
expect($result)->toBe(5);
expect($this->calculator->getHistory())->toHaveCount(1);
});
it('performs multiply operation', function() {
$result = $this->calculator->multiply(4, 5);
expect($result)->toBe(20);
expect($this->calculator->getHistory())->toHaveCount(1); // 現在會通過
});
});
beforeEach
是在每個測試案例執行「之前」都會執行的函數,用來設置測試環境。
建立 app/Math/CalculatorWithHistory.php
:
<?php
namespace App\Math;
class CalculatorWithHistory
{
private array $history = [];
public function add(int|float $a, int|float $b): int|float
{
$result = $a + $b;
$this->recordOperation('add', [$a, $b], $result);
return $result;
}
public function multiply(int|float $a, int|float $b): int|float
{
$result = $a * $b;
$this->recordOperation('multiply', [$a, $b], $result);
return $result;
}
public function getHistory(): array
{
return $this->history;
}
public function getLastResult(): int|float|null
{
return count($this->history) > 0 ? end($this->history)['result'] : null;
}
public function clearHistory(): void
{
$this->history = [];
}
private function recordOperation(string $operation, array $operands, int|float $result): void
{
$this->history[] = [
'operation' => $operation,
'operands' => $operands,
'result' => $result,
];
}
}
建立 tests/Unit/Day05/CalculatorLifecycleTest.php
:
<?php
use App\Math\CalculatorWithHistory;
describe('calculator with history', function() {
beforeEach(function() {
// 每個測試開始前都創建一個全新的計算器
$this->calculator = new CalculatorWithHistory();
});
describe('basic operations', function() {
it('performs addition', function() {
$result = $this->calculator->add(2, 3);
expect($result)->toBe(5);
expect($this->calculator->getHistory())->toHaveCount(1);
expect($this->calculator->getLastResult())->toBe(5);
});
it('performs multiplication', function() {
$result = $this->calculator->multiply(4, 5);
expect($result)->toBe(20);
expect($this->calculator->getHistory())->toHaveCount(1); // 現在每個測試都是乾淨的
expect($this->calculator->getLastResult())->toBe(20);
});
});
describe('history functionality', function() {
it('records single operation', function() {
$this->calculator->add(2, 3);
$history = $this->calculator->getHistory();
expect($history)->toHaveCount(1);
expect($history[0]['operation'])->toBe('add');
expect($history[0]['operands'])->toBe([2, 3]);
expect($history[0]['result'])->toBe(5);
});
it('records multiple operations', function() {
$this->calculator->add(2, 3);
$this->calculator->multiply(4, 5);
$history = $this->calculator->getHistory();
expect($history)->toHaveCount(2);
expect($history[0]['operation'])->toBe('add');
expect($history[1]['operation'])->toBe('multiply');
});
it('clears history', function() {
$this->calculator->add(2, 3);
$this->calculator->multiply(4, 5);
expect($this->calculator->getHistory())->toHaveCount(2);
$this->calculator->clearHistory();
expect($this->calculator->getHistory())->toHaveCount(0);
expect($this->calculator->getLastResult())->toBe(null);
});
});
});
afterEach
是在每個測試案例執行「之後」都會執行的函數,用來清理測試環境。
describe('tests requiring cleanup examples', function() {
beforeEach(function() {
$this->resource = ['active' => false, 'data' => []];
});
afterEach(function() {
// 確保每個測試後都清理資源
$this->resource['active'] = false;
$this->resource['data'] = [];
});
it('uses resource', function() {
$this->resource['active'] = true;
$this->resource['data'][] = 'test';
expect($this->resource['active'])->toBe(true);
expect($this->resource['data'])->toHaveCount(1);
});
});
始終保持設置(setup)和清理(cleanup)的對稱性:
describe('resource management examples', function() {
beforeEach(function() {
$this->resource = new SomeResource();
$this->resource->initialize();
});
afterEach(function() {
$this->resource->cleanup();
});
});
今天我們深入學習了測試生命週期的重要概念:
測試生命週期是確保測試穩定性和可靠性的基礎。通過適當的設置和清理:
記住:良好的測試生命週期管理是可靠測試的基石。
明天我們將學習「參數化測試」,了解如何用同一個測試邏輯驗證多組不同的資料,讓測試更加高效和全面。